Глава 4. Фундаментальные основы .Net

Инструмент для просмотра машинного кода .net и исполняемых файлов
https://github.com/dnSpyEx/dnSpy/releases/tag/v6.5.1
Очень удобно

Устройство простой dll

Каждая секция содержит в начале поля:

  1. Начальный адрес в виртуальной памяти
  2. Размер в виртуальной памяти
  3. Начальный адрес в файле
  4. Размер секции в файле
    В конце есть поле о числе строк, но оно устарело, применялось в с++ для дебага. Сейчас вся инфа о строках хранится в pdb

Общая структура файла такая:

  1. PE Заголовок, тут все по базе, заголовок только чтобы сказать что мы PE
  2. DOS Заголовок как легаси, содержит строку о том что DOS не поддерживаем
  3. Обычный заголовок с общей системной инфой
  4. Описание секции текст, секция нужна для хранения кода и метаданных
  5. Секция ресурсов, нужно например для иконки
  6. Секция reloc нужна для инфы о смещениях которые нужно совершить, если начальный адрес из заголовков PE придется смещать (оно для ОС)
  7. Заголовок Cor20 он же Clr заголовок. Содержит ссылку на начало метаданных
  8. Заголовок метаданных хранит описание 5 потоков. Включает в себя название, размер потока и отступ от начала метаданных
#~ или #- Таблицы метаданных (compressed / uncompressed формат)
#Strings Таблица строк (имена классов, методов и пр.)
#US User Strings (строковые литералы из IL)
#Blob Сигнатуры методов, массивы, параметры
#GUID GUID'ы сборки

Pasted image 20250505003124.png

Отдельно про поток #~
Pasted image 20250505011103.png
в нем хранятся таблицы метаданных
Каждая таблица — это структурированный набор записей, похожий на строки базы данных. Каждая строка описывает что-то: тип, метод, атрибут и т.д.

Таблица Назначение
00 Module Информация о текущем модуле (сборке .dll/.exe). Одна строка.
01 TypeRef Ссылки на типы, определённые в других сборках.
02 TypeDef Определения всех типов в этой сборке.
06 MethodDef Все методы, определённые в сборке.
08 Param Описания параметров методов.
0A MemberRef Ссылки на поля/методы, определённые вне текущей сборки.
0C CustomAttribute Все кастомные атрибуты (например, [Serializable]).
11 StandAloneSig Сигнатуры (например, для лямбд или delegate).
1B TypeSpec Специализации generic-типов (например, List<int>).
20 Assembly Информация о сборке (имя, версия и пр.).
23 AssemblyRef Ссылки на внешние сборки.
2B MethodSpec Generic-специализации методов (Do<T>()).

Для обращения к метаданных из IL кода применяются токены
формат токена
Токен = [таблица или поток (8 бит) ] + [индекс для таблиц/смещение для потока со строками(24 бита)]

Таблицы представлены буквально как таблицы в постгре, то есть идут записи подряд
структура каждой таблицы метаданных в потоке #~ жёстко задана стандартом ECMA-335 — то есть она не описывается в самом PE-файле

Пример структуры таблицы:

TypeDef Row Layout (обычно):

  • Flags (4 байта)
  • TypeName (index into #Strings)
  • TypeNamespace (index into #Strings)
  • Extends (coded index to TypeDefOrRef)
  • FieldList (index to Field table)
  • MethodList (index to Method table)

Домены приложений и сборки

В .Net домены приложений, AppDomains - это изначально механизм изоляции выполнения программ внутри одного процесса. Данный механизм реализовывался на уровне CLR.

AppDomain - это логически изолированная среда. Она:

  1. Изолирует сборки, сборка обычно это длл или ехе
  2. Позволяет динамически загружать и выгружать их
    Благодаря раздельным таким подпроцессам в рамках одного процесса, можно было загружать внешние модули, не опасаясь, что они повредят программу.
    До появления async Task, Домены были способом параллельного исполнения

В .net Core, механизм изоляции и выгрузки больше не поддерживается полноценно.

Осталось:

  1. В приложении существует AppDomain.CurrentDomain - текущий домен приложения, он всегда один.
  2. Доступны свойства текущего домена

Что удалено:

  1. Создание новых доменов
  2. Выгрузка доменов
  3. Изоляция кода по доменам

Применяются отдельные процессы и AssemblyLoadContext()

Теперь таким образом можно создавать свои контексты сборки и загружать туда длл в виде файла.

пример кода с загрузкой длл

var context = new AssemblyLoadContext("PluginContext", isCollectible: true);
var assembly = context.LoadFromAssemblyPath("/plugins/MyPlugin.dll");
var type = assembly.GetType("MyPluginNamespace.MyPluginClass");
var instance = Activator.CreateInstance(type);

Процесс выгрузки сборки, сработает только если isCollectible: true

var context = new AssemblyLoadContext("UnloadableContext", isCollectible: true);
Assembly assembly = context.LoadFromAssemblyPath("/path/to.dll");

// Работа с типами...

context.Unload(); // Помечает на выгрузку
GC.Collect(); GC.WaitForPendingFinalizers(); // Чтобы реально выгрузилось

⚠️ Важно: выгрузка работает только при отсутствии живых ссылок на типы из загруженной сборки.

По умолчанию .NET использует AssemblyLoadContext.Default.

Когда вы создаёте новый AssemblyLoadContext, CLR создает изолированное пространство загрузки, где типы и сборки не пересекаются с другими контекстами.

Может быть сборкой-разгружаемым (isCollectible: true) или обычным (в памяти навсегда).

Сравнение уровней изоляции доменов и контекстов

Характеристика AppDomain (.NET Framework) AssemblyLoadContext (.NET Core / .NET 5+)
Изоляция памяти Частичная — данные и стеки изолированы Только изоляция сборок (DLL), память общая
Изоляция объектов Полная: нельзя передать объекты напрямую Нет: объекты общие, если типы совпадают
Механизм безопасности (CAS херня для безопасности кода из винды) Поддерживает CAS (ограничения прав) Не поддерживается
Граница сериализации Да (можно передавать только сериализуемые) Нет: объект = объект
Сборка мусора Уничтожается вся область домена Выгружается только если isCollectible = true

Тип определяется не только по имени, но и по контексту загрузки сборки.

Итог про домены, сборки и контексты

Домен это мега изоляция почти как процессы, но на уровне CLR, но все это обрезали в новых версиях

В рамках одного процесса может быть несколько контекстов, в рамках одного контекста свои переменные и типы. Типы и переменные из двух разных контекстов взаимодействовать друг с другом не могут.
Но! В рамках одного контекста может быть несколько сборок (То есть DLL), и переменные между ними могут взаимодействовать, но нужно учитывать что описание типа - не только название но и сборка. Поэтому, например для каста между разными сборками, можно сделать сборку contracts.dll где описать общие для этих двоих интерфейсы и касты между ними.
Пример кастов между сборками в рамках одного контекста:

var context = new AssemblyLoadContext("MyContext");

var contracts = context.LoadFromAssemblyPath("Contracts.dll");
var pluginA = context.LoadFromAssemblyPath("PluginA.dll");
var pluginB = context.LoadFromAssemblyPath("PluginB.dll");

var typeA = pluginA.GetType("PluginA.MyPlugin");
var typeB = pluginB.GetType("PluginB.MyPlugin");

var objA = Activator.CreateInstance(typeA);
var objB = Activator.CreateInstance(typeB);

// Получаем Type интерфейса из загруженной Contracts.dll
var interfaceType = contracts.GetType("Contracts.IPlugin");

// ✅ Кастинг сработает, потому что это один и тот же Type
bool isAPlugin = interfaceType.IsInstanceOfType(objA); // true
bool isBPlugin = interfaceType.IsInstanceOfType(objB); // true

// Можно привести:
var plugin = (dynamic)objA;
plugin.Run(); // если метод Run есть

Существование контекстов обусловлено системами, которые могут одновременно использовать библиотеки разных версий, и чтобы изолировать типы этих библиотек, их можно подключать в разных контекстах.

Области памяти процесса

Pasted image 20250513020252.png
Commited память включает в себя ту память, которую ОС выложила на реальную, но возможно на swap возможно в рам
Private WS это память которая выложена в именно в РАМ и которая не общая

Shareable (около 2 ГиБ) – разделяемая память, которая нас не особенно интересует; Эти области служат для целей системного управления, вообще не имеющих отношения к .NET;

Mapped File (около 4 МиБ) – как отмечалось в главе 2, эти области содержат проецируемые файлы, в частности шрифты и файлы локализации. Хотя они и читаются средой выполнения .NET с применением различных API локализации, никаких проблем нашему приложению они создавать не должны;

Image (около 37 МиБ) – двоичные образы, содержащие различные исполняемые файлы .NET, включая саму среду выполнения и нашу сборку. Отметим, что большая часть этой области разделяемая, и лишь 772 КиБ входят в частный рабочий набор. Это файлы, которые читаются с диска на этапе запуска приложения;

Stack (около 4,5 МиБ) – в нашем приложении Hello World три потока, поэтому для них отведено три области под стеки;
Pasted image 20250513020544.png

Heap и Private Data (около 9 МиБ) – это различные области памяти, которые среда выполнения .NET использует для собственных целей. Среди них есть фундаментальные структуры данных например:

  • список пометки и таблицы карт, с которыми мы познакомимся в главах 5, 8 и 11;
  • здесь хранятся данные о регистрации интернированных строк;
  • Последние области памяти помечены флагами защиты Выполнение/Чтение/Запись. Сюда JIT-компилятор помещает машинный код при компиляции CIL-кода. Потому-то они и помечены флагом выполнения (Execute), поскольку этот код должен допускать вызов, как любой другой.
    Если по какой-то причине в нашем приложении часто производится JIT-компиляция, то мы будем наблюдать постоянный рост таких частных областей с флагами Выполнение/Чтение/ Запись; 
    
  • различные временные области, необходимые во время JIT-компиляции;
    Pasted image 20250513021156.png
    Managed Heap включает в себя:
    1. Куча GC – самая важная для нас куча, которой управляет сборщик мусора. Большая часть типов нашего приложения создается здесь.
    2. Куча загрузчика, содержит много областей необходимых домену для работы CLR
    1. Высокочастотная куча, содержит те данные, к которым CLR часто обращается, например здесь хранятся описания методов и полей. Здесь же находятся статические данные примитивных типов
    2. Низкочастотная куча, тут хранится то что CLR слабо редко нужно
    3. Много куч с данными для работы CLR
    По причине того, что куча загрузчика никогда не чиститься, если мы будем динамически загружать в память много-много типов, то потребление памяти будет быстро расти и никогда не падать.

Page Table (небольшая область размером 36 КиБ) – таблица страниц

Немного про устройство стека

Существует стек вызова, это наш привычный стек, он делится на фреймы. Фрейм под каждый метод. Каждый фрейм состоит из 4 логических участков памяти:

  • Аргументы метода - Обычные аргументы
  • Локальные переменные - Обычные переменные
  • Стек вычислений - Это как раз тот стек, необходимый для работы CLR виртуальной машины, значения от сюда работают с IL командами.
  • Локальный пул памяти - вспомогательный участок памяти для временных переменных для JIT вне стека вычислений, нужно для каких то временных переменных.

Система Типов

Существует мнение, что тип значений хранится на стеке, а типы ссылочные на куче. Это бред.

Разница в том, что тип значений содержит сразу всю необходимую информацию в своем описании. Например инт содержит все что нужно сразу в себе.
А ссылочные типы содержат только ссылку.

А то, где хранится тип, зависит от случая

Тип значений

Тип значений может хранится как на стеке так и на куче. Это зависит от контекста, пример:

  1. Переменная метода, тут удобнее на стеке
  2. Параметр метода также удобнее на стеке
  3. Статическое поле, оно живет также долго как тип, поэтому на куче.
  4. Поле ссылочного типа, объект ссылочного типа может жить очень долго, так что на куче вместе с самим объектом.
  5. Локальный пул памяти - эта область тесно связана с методом, так что на стеке
  6. На стеке вычислений, это также тесно связано с методом, так что на стеке или в регистре

По причине того, что структуры могут попасть в регистры, что они могут хранится на стеке, что они не наследуются, а еще что при хранении у них нет доп данных, только поля, они могут очень сильно оптимизировать код (Например пользуясь предсказуемостью структур, JIT может их сам оптимизировать как хочет)

(Важно, что структуры не хранят в памяти рядом с собой никаких доп информаций, даже ссылки на описание класса, только поля. Для вызова метода напрямую пишется команда на вызов метода из таблицы методов)

Ссылочные типы

Классы

В отличие от структуры, экземпляр класса содержит много дополнительной информации, а именно
заголовок объекта – место для «любой дополнительной информации. Часто заголовок просто содержит нули, но обычно он используется для хранения информации о блокировке, поставленной на объект, или для кеширования значения, вычисленного методом GetHashCode. Заголовок используется по принципу «кто первый встал, того и тапки». Если среде выполнения он понадобился для хранения блокировки, то хеш-код в нем уже не хранится.
ссылка на таблицу методов – как уже было сказано, «тип объекта явно хранится в его представлении», и, с точки зрения реализации, это и есть таблица методов (MethodTable). Именно сюда указывают все внешние ссылки на объект, т. е. если на объект имеются какие-то ссылки, то все они содержат адрес хранящейся в нем ссылки на таблицу методов. Поэтому говорят, что заголовок объекта имеет «отрицательный индекс». Ссылка на таблицу методов сама является указателем на часть структуры, содержащей описание типа (она находится в высокочастотной куче домена, содержащего этот тип);
факультативный заместитель данных, если в типе нет ни одного поля. Сборщик мусора настаивает на том, чтобы в каждом объекте было место хотя бы для одного поля такой длины, как указатель. Это поле используется для разных целей, но каких - сложно.

Таким образом, в 64 разрядной системе, минимальный размер экземпляра объекта равен 24 байта

8 байт для заголовка объекта  – из которых на самом деле используются только 4, а остальные 4 заполнены нулями (т. к. в 64-разрядной архитектуре область памяти должна быть выровнена на 8-байтовую границу);
8 байт (размер указателя) для ссылки на таблицу методов;
8 байт (размер указателя) для внутреннего заместителя данных.
Pasted image 20250526231422.png

Строки

Чуть глубже про интернирование, в CLR в неуправляемой куче лежит структура, словарь строк с адресом внутри другой структуры. Другая структура это тоже словарь, но уже в куче больших объектов, и уже в ней лежат ссылки на строки как на обычные объекты.
Pasted image 20250527001834.png

Интересный способ избежать неявную упаковку

В случае если параметр метода принимает интерфейс, то при передаче структуры реализующей этот интерфейс, происходит упаковка, что плохо. Для избегания этого, можно применить финт:

void Method<T>(T parametr) where T : ISomeInterface 

В таком случае, во время исполнения, для передаваемой структуры будет сгенерирована реализация как для структуры и упаковки не будет.

==ВАЖНО! Для List при вызове GetEnumerator() сам инумератор является структурой, а значит происходит упаковка в foreach, за этим надо следить, и если много форычей, то делать финг выше ==